Skip to content

fix: render custom Pages Router 404 for getStaticProps/getServerSideProps notFound results - #1346

Open
rome2o wants to merge 2 commits into
opennextjs:mainfrom
rome2o:fix/pages-router-render404
Open

fix: render custom Pages Router 404 for getStaticProps/getServerSideProps notFound results#1346
rome2o wants to merge 2 commits into
opennextjs:mainfrom
rome2o:fix/pages-router-render404

Conversation

@rome2o

@rome2o rome2o commented Aug 17, 2026

Copy link
Copy Markdown

Problem

When a Pages Router page's getStaticProps/getServerSideProps returns { notFound: true }, Next.js internally calls routerServerContext.render404(...) to render the app's actual pages/404/pages/_error. Under @opennextjs/cloudflare, this is undefined for the first request(s) a fresh Worker isolate handles, so Next.js falls back to a bare, hardcoded "This page could not be found" body with no Content-Type, no HTML, no styling — instead of the app's actual 404 page.

Root cause

opennextjs-cloudflare builds a bare NextServer (via @opennextjs/aws's dist/core/util.js) and calls its request handler directly — no Next.js "router-server" process ever runs. Next.js's Pages Router route handler (next/dist/server/route-modules/pages/pages-handler.js) falls back to the hardcoded string whenever routerServerContext.render404 is unavailable. routerServerContext is read from a global registry (routerServerGlobal[RouterServerContextSymbol][relativeProjectDir]) that NextNodeServer only self-populates lazily, inside handleCatchallRenderRequest — i.e. only once a genuinely unmatched path is hit.

Any request that matches a real page whose getStaticProps/getServerSideProps returns { notFound: true } can be the very first request handled by a fresh Worker isolate, before that lazy registration ever runs.

Fix

Patch NextServer#makeRequestHandler() (next/dist/server/next-server.js) to perform the same self-registration unconditionally, before the request handler is returned — so it runs before any request, matched or not, can reach the route module. This reuses this.render404, the same method that already correctly renders the custom 404 page for genuinely unmatched paths. The registration key is hardcoded to "" (relativeProjectDir's build-time constant value) since OpenNext always constructs NextServer with dir: "".

This mirrors the fix Cloudflare's vinext project shipped for the equivalent Pages Router bug: rerouting notFound results to the app's actual 404/error page instead of a built-in fallback. See cloudflare/vinext#1737 and cloudflare/vinext#2773 (header preservation follow-up).

Only render404 and nextConfig are registered — deliberately not isWrappedByNextServer (see review fix below).

Testing

  • Unit test added in next-server.spec.ts covering the new registerRouterServerContextRule ast-grep patch rule; full unit suite passes (343/343).
  • New e2e regression test (examples/e2e/pages-router/e2e/ssr-not-found.test.ts) against a getServerSideProps route that always returns { notFound: true } — deliberately chosen because getServerSideProps re-runs on every request, so it can be the very first request a fresh Worker isolate handles (unlike a getStaticProps notFound result, which resolves at build time). Asserts the response is a full rendered HTML document (<!DOCTYPE html>) and not the literal bare fallback string.
  • Manually verified against real wrangler/workerd (not just the test harness): built and booted both the unpatched (main) and patched worker, and diffed the raw HTTP response for the same route:
    • Before: HTTP/1.1 404 with no Content-Type, body = literal This page could not be found (29 bytes)
    • After: HTTP/1.1 404 with Content-Type: text/html; charset=utf-8, full rendered _error/404 page HTML (head tags, CSS, JS chunks)
  • Review fix (9ac06a9): Devin Review correctly flagged that the initial version also set isWrappedByNextServer = true, which flips RouteModule#prepare() (route-module.js) from serverUtils.normalizeQueryParams(...) to serverUtils.filterInternalQuery(...) — a branch that's only correct when an upstream router-server process has already normalized the query, which never happens under OpenNext. This would have silently deleted nxtP-/nxti-prefixed dynamic route params on every request instead of decoding them. Removed that assignment (only render404/nextConfig are needed) and manually re-verified against /api/dynamic/[slug] in the pages-router example: params resolve correctly ({"slug":"hello-world-123"}) with the flag removed, and the 404 fix and e2e regression test both still pass.
  • pnpm --filter cloudflare build, ts:check, and lint:check all clean.
  • Not yet run: full e2e matrix across app-router/app-pages-router examples (this bug is Pages-Router-specific, but flagging as not yet covered by this PR).

Changeset

Included (patch bump for @opennextjs/cloudflare).

…rops notFound results

Root cause: opennextjs-cloudflare builds a bare `NextServer` (via
@opennextjs/aws's dist/core/util.js) and calls its request handler
directly, without ever running a Next.js "router-server" process.
Next.js's Pages Router route handler
(next/dist/server/route-modules/pages/pages-handler.js) falls back to
a hardcoded `"This page could not be found"` body whenever
`routerServerContext.render404` is unavailable. `routerServerContext`
is read from a global registry
(routerServerGlobal[RouterServerContextSymbol][relativeProjectDir])
that NextNodeServer only self-populates lazily, inside
`handleCatchallRenderRequest` - i.e. only once a genuinely unmatched
path is hit. Any request that matches a real page whose
getStaticProps/getServerSideProps returns `{ notFound: true }` can be
the first request handled in a Worker isolate, before that lazy
registration ever runs, so the app's actual pages/404/pages/_error is
never rendered.

Fix: patch NextServer#makeRequestHandler() (next/dist/server/next-server.js)
to perform the same self-registration unconditionally, before the
request handler is returned and therefore before any request -
matched or not - can reach the route module. This reuses
`this.render404`, the same method that already correctly renders the
custom 404 page for genuinely unmatched paths. The registration key
is hardcoded to "" (relativeProjectDir's build-time constant value)
since OpenNext always constructs NextServer with dir: "".

This mirrors the fix Cloudflare's vinext project shipped for the
equivalent Pages Router bug: rerouting `notFound` results to the app's
actual 404/error page instead of a built-in fallback.
See cloudflare/vinext#1737 and cloudflare/vinext#2773 (header
preservation follow-up).

Adds an e2e regression test (ssr-not-found) asserting the actual
404/error page body is served, not just a 404 status code.
@changeset-bot

changeset-bot Bot commented Aug 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9ac06a9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@opennextjs/cloudflare Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +224 to +225
_routerservercontext.routerServerGlobal[_routerservercontext.RouterServerContextSymbol][relativeProjectDir].nextConfig = this.nextConfig;
_routerservercontext.routerServerGlobal[_routerservercontext.RouterServerContextSymbol][relativeProjectDir].isWrappedByNextServer = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Query parameters for dynamic pages may be silently dropped on every request

The patch turns on Next.js's "hosted behind a router server" flag for the whole app (isWrappedByNextServer = true at packages/cloudflare/src/cli/build/patches/plugins/next-server.ts:225) even though no router server exists here, so Next.js skips the step that turns internally-prefixed routing parameters back into real page parameters and instead deletes them.
Impact: Pages that receive route parameters through internal prefixed query values can render with missing parameters, producing wrong or empty content instead of the expected page.

Why the registration flips a query-normalization branch that was previously never taken

Next reads the context in RouteModule#prepare() using the key getRequestMeta(req, 'relativeProjectDir') || this.relativeProjectDir (route-module.js:287-288 in next@15.5.21). Under OpenNext that key is "", while Next's own lazy self-registration in handleCatchallRenderRequest writes under path.relative(process.cwd(), this.dir) — which the PR description itself observed to be ".." at runtime. So before this patch the lookup at key "" returned undefined and route-module.js:391 took the serverUtils.normalizeQueryParams(query, routeParamKeys) branch, which strips the nxtP/interception prefixes, decodes the values and re-adds them as real query keys, populating routeParamKeys.

After this patch an entry now exists at key "" with isWrappedByNextServer = true, so the other branch runs: serverUtils.filterInternalQuery(query, []), which simply deletes every nxtP-/nxti-prefixed key and leaves routeParamKeys empty. That branch is only correct when an upstream router server has already normalized the query, which is not the case for OpenNext.

If only render404 is needed for the Pages Router notFound fix, registering render404 (and optionally nextConfig) without setting isWrappedByNextServer keeps the previous query handling intact.

Prompt for agents
The new registerRouterServerContextRule in packages/cloudflare/src/cli/build/patches/plugins/next-server.ts injects a routerServerContext entry under the key "" and sets isWrappedByNextServer = true. In Next's route-module.js (RouteModule#prepare), that flag selects filterInternalQuery(query, []) instead of serverUtils.normalizeQueryParams(query, routeParamKeys). Because OpenNext has no upstream router server performing the normalization, and because before this patch the lookup at key "" always missed (Next's own lazy registration writes under path.relative(process.cwd(), this.dir), observed to be ".."), the normalizeQueryParams branch was the one always taken. Setting the flag therefore changes query handling for every request, causing nxtP-/nxti-prefixed routing params to be deleted rather than decoded into real query params, and leaving routeParamKeys empty for the subsequent dynamic-route param interpolation. Consider registering only render404 (and nextConfig if needed) without isWrappedByNextServer, and add e2e coverage for dynamic routes with rewrites/middleware to confirm params still resolve.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 9ac06a9. Dropped the isWrappedByNextServer = true assignment; only render404 (and nextConfig, already read elsewhere in RouteModule) is needed for the notFound fix. Verified manually that /api/dynamic/[slug] still resolves params correctly with it removed, and the e2e regression test still passes.

…text

Setting isWrappedByNextServer flips RouteModule#prepare() (route-module.js)
from serverUtils.normalizeQueryParams(...) to serverUtils.filterInternalQuery(...),
which is only correct when an upstream router-server process has already
normalized the query. OpenNext has no such process, so this deleted
nxtP-/nxti-prefixed routing params instead of decoding them into real page
params on every request.

Only render404 (and nextConfig, used elsewhere in RouteModule) is needed for
the Pages Router notFound fix, so drop the isWrappedByNextServer assignment.

Verified manually: /api/dynamic/[slug] resolved params correctly with the fix
removed, and the ssr-not-found e2e regression test still passes.

Found by Devin Review on PR opennextjs#1346.
@rome2o

rome2o commented Aug 27, 2026

Copy link
Copy Markdown
Author

@vicb - Please let me know if we need anything changed here!

@vicb

vicb commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

I'll get to that but not before next week.

One question for you in the meantime: the cloudflare adapter is based off the aws adapter. Should it be fixed there instead? The repo is https://github.com/opennextjs/opennextjs-aws - it should be easy enough to test the behavior there.

Thanks!

@pkg-pr-new

pkg-pr-new Bot commented Aug 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@opennextjs/cloudflare@1346

commit: 9ac06a9

@rome2o

rome2o commented Aug 28, 2026

Copy link
Copy Markdown
Author

Superseded by opennextjs/opennextjs-aws#1229, which fixes this in the shared core.

Not a fresh-isolate race as I originally described, it is a permanent key mismatch: Next registers the context under path.relative(process.cwd(), this.dir), the route modules read it under the build-time relativeProjectDir (""). AWS only lines up thanks to process.chdir(__dirname); workerd has no chdir.

Verified here with the ast-grep patch disabled: pages-router e2e green, 37 passed, 1 skipped. Happy to close once #1229 merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants